Skip to content

feat: add explicit OpenCode host import and repo relink#176

Open
itz4blitz wants to merge 2 commits intochriswritescode-dev:mainfrom
itz4blitz:feat/host-import-resync
Open

feat: add explicit OpenCode host import and repo relink#176
itz4blitz wants to merge 2 commits intochriswritescode-dev:mainfrom
itz4blitz:feat/host-import-resync

Conversation

@itz4blitz
Copy link
Copy Markdown
Contributor

Summary

  • add an explicit Settings flow to import existing OpenCode host config and state after initial setup instead of only on first boot
  • relink imported session directories to their nearest git repo roots so matching repos are automatically registered and existing chats reconnect without manual rediscovery
  • surface import detection and relink results in the OpenCode Config settings UI so Docker users can see what was found and what got linked

Why

OpenCode Manager already had the building blocks for reconnecting existing sessions, but the actual host import path was still mostly a first-run bootstrap behavior tied to empty workspace state.

That made Docker and existing-host setups fragile: if the initial import window was missed or volumes already existed, users had to manually bind paths, rediscover repos, and guess why old sessions were not appearing.

This change turns that into an explicit, repeatable workflow from Settings and uses imported session directories to relink the right repositories automatically.

What Changed

  • extracted reusable host config/state import logic into backend/src/services/opencode-import.ts
  • reused that service for startup bootstrap and for a new manual import endpoint
  • added GET /api/settings/opencode-import/status and POST /api/settings/opencode-import
  • added repo relink logic that resolves imported session.directory paths to nearest git repo roots and registers them via the existing sourcePath flow
  • added a Settings card showing detected host config/state paths, workspace state path, and relink results after import

Validation

  • pnpm --filter backend exec vitest test/routes/settings.test.ts test/services/opencode-import.test.ts test/services/repo.test.ts
  • pnpm test
  • pnpm lint
  • pnpm build

Notes

  • relinking still depends on path visibility from the Manager runtime to the original repo paths, which is the same underlying requirement as the existing session-reconnect design
  • non-repo session directories are skipped and reported in the relink summary instead of failing the whole import

Copilot AI review requested due to automatic review settings April 1, 2026 20:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an explicit, repeatable OpenCode host import workflow (status + manual sync) and uses imported session directories to automatically relink repositories so existing chats can reconnect, with corresponding UI surfaced in Settings.

Changes:

  • Extracts OpenCode host config/state import into a reusable backend service and reuses it for startup + a new manual import API.
  • Adds repo relink logic that maps imported session directories to nearest git repo roots and registers them.
  • Updates the Settings UI to show import detection status and relink results, plus adds/updates supporting API types and tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
frontend/src/components/settings/OpenCodeConfigManager.tsx Adds Settings card + actions to fetch import status and trigger host import, showing relink results.
frontend/src/api/types/settings.ts Introduces OpenCodeImportStatus and SyncOpenCodeImportResponse types for the new import endpoints.
frontend/src/api/settings.ts Adds client functions for GET /api/settings/opencode-import/status and POST /api/settings/opencode-import.
backend/src/services/repo.ts Adds relinkReposFromSessionDirectories and helper to find nearest git repo root.
backend/src/services/opencode-import.ts New service to detect/import host config + snapshot state DB + read imported session directories.
backend/src/routes/settings.ts Adds OpenCode import status + sync endpoints and wires relink + restart into the workflow.
backend/src/index.ts Replaces bootstrap import logic with calls into the new opencode-import service; updates settings route wiring.
backend/test/services/repo.test.ts Adds coverage for relinking session directories to repo roots.
backend/test/services/opencode-import.test.ts Adds coverage for import detection, import execution, and reading session directories.
backend/test/routes/settings.test.ts Adds coverage for new import routes and updates route creation to pass git auth service.

configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath)
}

if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) {
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stateImported is set to true after importOpenCodeStateDirectory(...) even if the source directory is missing opencode.db (in which case importOpenCodeStateDirectory returns early and the workspace DB is not actually imported). Consider making importOpenCodeStateDirectory return a boolean (or throwing) so stateImported accurately reflects whether a usable DB snapshot was created, and so callers can surface a clear error.

Suggested change
if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) {
if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) {
const sourceStateDbPath = path.join(status.stateSourcePath, 'opencode.db')
if (!await fileExists(sourceStateDbPath)) {
throw new Error(`OpenCode state source directory "${status.stateSourcePath}" is missing required file "opencode.db"`)
}

Copilot uses AI. Check for mistakes.
Comment on lines +150 to +168
const status = await getOpenCodeImportStatus()
const userId = options.userId || 'default'
let configImported = false
let stateImported = false

if (status.configSourcePath) {
configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath)
}

if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) {
await importOpenCodeStateDirectory(status.stateSourcePath, status.workspaceStatePath)
stateImported = true
}

return {
...status,
configImported,
stateImported,
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncOpenCodeImport returns the OpenCodeImportStatus captured before performing imports (...status). After a successful state import, workspaceStateExists in the response can be stale/misleading. Recompute workspaceStateExists (or re-run getOpenCodeImportStatus) after import so the returned status reflects the post-import workspace state.

Copilot uses AI. Check for mistakes.
Comment on lines +403 to +444
for (const directory of directories) {
const normalizedDirectory = normalizeInputPath(directory)
if (!normalizedDirectory) {
continue
}

const repoRoot = await findGitRepoRoot(normalizedDirectory, env)
if (!repoRoot) {
continue
}

uniqueRepoRoots.add(repoRoot)
}

const repos: Repo[] = []
let relinkedCount = 0
let existingCount = 0

for (const repoRoot of Array.from(uniqueRepoRoots).sort((left, right) => left.localeCompare(right))) {
try {
const result = await registerExistingLocalRepo(database, gitAuthService, repoRoot)
repos.push(result.repo)
if (result.existed) {
existingCount += 1
} else {
relinkedCount += 1
}
} catch (error: unknown) {
errors.push({
path: repoRoot,
error: getErrorMessage(error),
})
}
}

return {
repos,
relinkedCount,
existingCount,
skippedCount: Math.max(0, directories.length - uniqueRepoRoots.size),
errors,
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skippedCount is computed as directories.length - uniqueRepoRoots.size, which counts duplicate session directories from the same repo root as “skipped”. The UI text and PR description imply this represents “non-repo session paths”, so the metric can be inaccurate. Track non-repo/invalid paths separately from duplicates (or adjust naming/response fields) so the summary matches actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +468 to +478
const result = await syncOpenCodeImportMutation.mutateAsync()
const importedParts = [result.configImported && 'config', result.stateImported && 'state']
.filter(Boolean)
.join(' and ')
const relinkSummary = result.relinkedRepos
? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.`
: ''
showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' })
} catch (error) {
showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' })
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try block inside the onClick handler is mis-indented (the const result / const importedParts lines are not aligned). This is likely to fail formatting/lint rules and makes the handler harder to read—please run the formatter or fix indentation.

Suggested change
const result = await syncOpenCodeImportMutation.mutateAsync()
const importedParts = [result.configImported && 'config', result.stateImported && 'state']
.filter(Boolean)
.join(' and ')
const relinkSummary = result.relinkedRepos
? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.`
: ''
showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' })
} catch (error) {
showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' })
}
const result = await syncOpenCodeImportMutation.mutateAsync()
const importedParts = [result.configImported && 'config', result.stateImported && 'state']
.filter(Boolean)
.join(' and ')
const relinkSummary = result.relinkedRepos
? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.`
: ''
showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' })
} catch (error) {
showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' })
}

Copilot uses AI. Check for mistakes.
<div className="rounded-lg border border-border p-3">
<p className="font-medium">Last Relink Result</p>
<p className="mt-1 text-muted-foreground">
Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths.
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI message says skippedCount is "non-repo session paths", but the backend currently counts duplicates (multiple session directories resolving to the same repo root) as skipped as well. Either adjust the backend metric or update this label so it matches what is being counted.

Suggested change
Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths.
Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} duplicate or non-repo session paths.

Copilot uses AI. Check for mistakes.
const MockSQLiteDatabase = SQLiteDatabase as unknown as ReturnType<typeof vi.fn>

describe('opencode-import service', () => {
const mockDb = {} as any
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid any in tests when possible. Here mockDb can be typed as unknown as Database (or never) to keep strict typing and match the style used in other tests in this repo.

Suggested change
const mockDb = {} as any
const mockDb = {} as unknown as SQLiteDatabase

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants